Skip to content

feat: packageExtensions for root-owned dependency manifest repairs#9496

Merged
owlstronaut merged 12 commits into
npm:latestfrom
manzoorwanijk:feat/package-manifest-extensions
Jun 18, 2026
Merged

feat: packageExtensions for root-owned dependency manifest repairs#9496
owlstronaut merged 12 commits into
npm:latestfrom
manzoorwanijk:feat/package-manifest-extensions

Conversation

@manzoorwanijk

Copy link
Copy Markdown
Contributor

Implements package manifest extensions per RFC #889: a root-only packageExtensions field in package.json that applies declarative repairs to third-party dependency manifests before Arborist finalizes the ideal tree. It lets a project add missing dependencies/optionalDependencies, add or correct peerDependencies, and mark peers optional via peerDependenciesMeta, without forking and republishing a package.

{
  "packageExtensions": {
    "broken-package@1": {
      "dependencies": { "missing-runtime-dep": "^2.0.0" }
    },
    "typescript-plugin@4.3.0": {
      "peerDependencies": { "typescript": ">=5" },
      "peerDependenciesMeta": { "typescript": { "optional": true } }
    }
  }
}

Why

install-strategy=linked gives installs strong package boundaries, which is also what makes adoption hard: a package only sees what it actually declared, so one that worked under a hoisted layout because a dependency happened to be hoisted above it can fail. A root-level dependency masks this under hoisting but does not make the package available inside the isolated boundary of the importer — the repair has to be attached to the broken package's manifest before its edges are resolved. This is the pre-resolution complement to overrides (which needs an existing edge to retarget) and to native dependency patching #9439 (which edits package contents after resolution).

The field

Each key is a package selector: a name with an optional semver range (foo, foo@1, @scope/foo@^2.3.0). Selectors match a candidate's own manifest name/version (the underlying name for aliases) and reject dist-tag, git, file, URL, and npm: specs. At most one selector may match a candidate. Honored only in the root package.json (the workspace root); the field in dependencies and non-root workspaces, and selectors matching a workspace member, are ignored with a warning — matching the root-authority model of overrides.

Merge semantics

Only the four resolution-affecting fields may be extended.

  • dependencies/optionalDependencies add a missing name only; providing a name already declared in either field is an error (use overrides to change a version), which also forbids moving a name between the two.
  • peerDependencies shallow-merges by name, replacing an existing range.
  • peerDependenciesMeta merges by name then key (e.g. add optional: true); every meta entry must have a corresponding peerDependencies entry.
  • Deletion (null/false/"-") is not supported.

The extension applies to a per-tree manifest copy: the shared pacote/cache manifest is never mutated, the installed node_modules/<pkg>/package.json is not rewritten, and bundleDependencies is unchanged. overrides still controls the final resolution target of an extension-created edge.

Lockfile

The root entry stores a canonical packageExtensionsHash, and each affected entry stores minimal provenance (packageExtensionsApplied); effective dependency metadata is recorded as usual. Extension state forces lockfileVersion: 4 so older npm clients abort rather than silently dropping the repaired graph. npm install re-resolves affected packages when the rule set changes; npm ci validates the hash, selector conflicts, and stale provenance before trusting the locked metadata.

Visibility

npm explain appends (added by packageExtensions["foo@1"].dependencies.bar) to the edge; npm ls annotates the node and npm ls --json includes packageExtensionsApplied. Publishing a non-private package containing the field warns that it does not affect consumers.

Notes

  • lockfileVersion: 4 is shared with native dependency patching (#9439) as a common "older npm must not silently drop this" tripwire; both bump only when their own state is present. Whichever lands second should reuse the same maxLockfileVersion/bump constants rather than introduce a competing version.
  • Opt-in and additive, so it can ship in a minor release.

References

Implements npm/rfcs#889

@manzoorwanijk manzoorwanijk marked this pull request as ready for review June 5, 2026 13:09
@manzoorwanijk manzoorwanijk requested review from a team as code owners June 5, 2026 13:09
@manzoorwanijk manzoorwanijk force-pushed the feat/package-manifest-extensions branch from 0deca0b to 29a8bf4 Compare June 8, 2026 05:22
@manzoorwanijk manzoorwanijk force-pushed the feat/package-manifest-extensions branch 2 times, most recently from e90d92f to 64989e3 Compare June 18, 2026 14:28
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Chaotic merge conflict 😆

@manzoorwanijk manzoorwanijk force-pushed the feat/package-manifest-extensions branch from 21da3e1 to c2114a1 Compare June 18, 2026 17:13
@manzoorwanijk manzoorwanijk force-pushed the feat/package-manifest-extensions branch from c2114a1 to c13cf4f Compare June 18, 2026 17:28
@owlstronaut owlstronaut merged commit ce7681f into npm:latest Jun 18, 2026
69 checks passed
@manzoorwanijk manzoorwanijk deleted the feat/package-manifest-extensions branch June 18, 2026 17:55
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Codex found one for isolated dependencies 😄 - #9568

owlstronaut pushed a commit that referenced this pull request Jun 18, 2026
…9569)

Follow-up of #9496

Under `install-strategy=linked`, a root `packageExtensions` rule that
adds a missing dependency installs and works at runtime, but every
command that reads the actual tree loses the edge:

- `npm ls --all --json` omits the extension-created dependency and its
provenance.
- `npm explain <dep>` fails with `No dependencies found matching <dep>`.
- `npm patch add <dep>` fails with `EPATCHNOTINSTALLED`.

Hoisted installs and normally-declared transitive deps are unaffected.

## References

Fixes #9568
owlstronaut pushed a commit that referenced this pull request Jun 18, 2026
…ch (#9570)

A workspace `packageExtensions` warning was printed twice per matching
workspace. A workspace appears in the inventory as two `isWorkspace`
nodes — the Link and its target — and `#warnWorkspacePackageExtensions`
warned for both. The fix skips the link (`node.isLink`) so each
workspace is warned once via its target node; the selector/`wouldMatch`
checks are otherwise unchanged.

## References

Follow up of #9496
owlstronaut pushed a commit to npm/rfcs that referenced this pull request Jun 18, 2026
Bot-generated transition of RFC **#55** to status `implemented`.

Moved to `implemented/0055-package-manifest-extensions.md`. Front-matter
`status` and the relevant date field were updated. `INDEX.md` was
regenerated.

Implementation: npm/cli#9496

Co-authored-by: npm CLI robot <npm-cli+bot@github.com>
owlstronaut pushed a commit to npm/rfcs that referenced this pull request Jun 19, 2026
## Summary

Adds an RFC for a root-owned `.npm-extension.mjs` / `.npm-extension.cjs`
file with a top-level `transformManifest(pkg, context)` extension point.

The proposal lets a project imperatively repair third-party package
manifests before Arborist reads dependency and peer edges. It builds on
the accepted [Package Manifest Extensions
proposal](#889) and its [npm CLI
implementation](npm/cli#9496), which established
the pre-resolution manifest repair phase, root-only authority model,
lockfile visibility model, and publish isolation for local dependency
metadata repairs.

`packageExtensions` remains the safer declarative default for common
metadata repairs. `.npm-extension` covers the cases where projects need
comments, upstream issue links, repeated transformations, conditional
logic, reading existing manifest values, deletion, dependency-range
replacement, or a local policy file that does not live in publishable
`package.json`.

## Motivation

`install-strategy=linked` makes dependency boundaries stricter by
avoiding accidental hoisting. That is useful for correctness, but it
exposes packages that import dependencies or type packages they did not
declare. The accepted `packageExtensions` RFC handles the most common
form of this problem: small deterministic additions to dependency and
peer metadata.

Some repairs are harder to keep clear as declarative JSON. During the
[Package Manifest Extensions proposal
discussion](#889 (comment)),
a Gutenberg migration example repeated the same optional `@types/react`
peer repair across many React-related dependencies. The declarative form
worked, but the underlying policy was really a named list or predicate:
"these packages import React types but do not declare an optional
`@types/react` peer."

Other local repairs need conditional logic, such as adding a type
package only when a matching runtime peer exists, copying an existing
peer range into a type dependency, narrowing a known bad peer range, or
throwing when upstream has fixed metadata and a local repair should be
removed. `packageExtensions` intentionally does not support that kind of
code.

This RFC proposes an explicit advanced escape hatch for those cases
while preserving npm's root-owned authority model, lockfile visibility,
and publish isolation.

## Why a separate extension file

The RFC intentionally keeps executable policy out of `package.json`. A
public package may need local dependency repairs for its own tests,
build, or linked-install migration, but it should not publish root-only
install policy to the registry manifest or packument.

`.npm-extension` is also deliberately not named `.npmfile` or shaped
like pnpm's `hooks.readPackage`. The proposal borrows the useful
manifest-transform idea from pnpm, but defines npm-specific semantics
for trust, lockfile hashing, `npm ci`, publish exclusion, disable
behavior, supported mutations, and future extension points.

## Notable semantics

- Only the root project owns `.npm-extension`.
- Workspace package manifests are not extension targets; non-root
workspace `.npm-extension` files warn and are ignored.
- Dependency package `.npm-extension` files are ignored.
- The only extension point in this RFC is top-level
`transformManifest(pkg, context)`.
- The extension point runs synchronously before dependency and peer
edges are read.
- Supported output changes are limited to `dependencies`,
`optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta`.
- Unlike `packageExtensions`, the imperative form can delete supported
dependency entries and replace existing normal dependency ranges.
- Unsupported field changes such as `scripts`, `bin`, `exports`,
`types`, and `bundleDependencies` are rejected.
- `transformManifest` runs for non-root, non-workspace dependency
manifests from registry, git, remote tarball, local file, local
directory, and symlinked dependency sources.
- npm records an extension entry-file hash and minimal
`npmExtensionApplied` provenance in `package-lock.json`.
- `npm ci` verifies matching extension state without importing or
executing `.npm-extension`.
- Changed extension file bytes make `npm install` re-run
`transformManifest` across candidate manifests rather than relying on
selector-based selective re-resolution.
- Extension-created, extension-changed, and extension-removed dependency
metadata is visible through lockfile provenance and npm inspection
output.
- `.npm-extension.mjs` and `.npm-extension.cjs` are excluded from `npm
pack` and `npm publish`, even when listed in `files`.
- `ignore-extension=true` disables extension execution, and
`ignore-scripts=true` implies `ignore-extension=true` for commands that
would otherwise execute it.

## Relationship to `packageExtensions`

This RFC is not trying to replace `packageExtensions`. The declarative
feature should remain the first choice for small, reviewable manifest
repairs. The imperative extension file is for cases where the
declarative shape becomes repetitive, cannot express the needed local
policy, or cannot live in a publishable package manifest.

The RFC keeps the two features ordered and auditable:
`transformManifest` runs before `packageExtensions`, then npm reads
dependency and peer edges from the resulting effective manifest. That
lets imperative repairs inspect the upstream manifest before declarative
repairs are applied, while preserving the accepted `packageExtensions`
validation and provenance model.

## References

Follow up of #889 

---

> **Disclosure**: [Codex](https://chatgpt.com/codex/) and [Claude
Code](https://claude.com/claude-code) were used to draft the initial
version of this RFC and to iterate on it during review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants